这是翻译《Effective Objective-C 2.0》的第五章:内存管理
简介
无论任何面向对象语言,内存管理都是重要的一部分,例如Objective-C
。想写出高效无bug的语言,对其语言的内存管理模型一定要了解。
如果你理解了类似规则,你会发现Objective-C
的内存管理貌似也不是很复杂,特别当你使用ARC时。ARC将所有的内存管理都交给了编译器,可以让开发者专注业务逻辑。
理解引用计数
Objective-C
使用引用计数去管理内存,这意味着每个对象都有一个计数器用于增加和减少引用计数。当你想使用某个对象时就增加引用计数,当你不再使用某个对象时,就减少引用计数。当对象引用计数为0,这个对象不再有任何使用者,这时他就会被释放。上面所述是主要概念,如果你想写出优秀的代码,即使你使用ARC(看第30节)机制,你也需要理解上述概念。
在Mac上,垃圾回收机制在10.8之后被废弃,在iOS上更是从来都不能使用。所以理解引用计数就至关重要了,因为你不能在iOS和Mac上使用垃圾回收了。
如果你已经使用了ARC,那你应该知道所有与引用计数相关的方法都无法调用了,暂且忘掉这些吧。在ARC中,这是真的。但是这是讲述引用计数必须的一部分,并且ARC也是一种引用计数机制,所以还是要讲述这些在ARC下无法使用的方法。
引用计数如何工作
在引用计数机制下,计数器是代表每个对象有多少事物想令此对象继续存在。这涉及到了一个叫做保留计数的东西,但是他也被称作引用计数。下面三个NSObject
的协议方法可以操作引用计数的增加和减少:
- retain 增加引用计数
- release 减少引用计数
- autorelease 稍后减少引用计数,当自动释放池释放时减少(我们将在第34节第150页讨论自动释放池)
其中有一个叫做retainCount
的方法,但是它不是很准确,即使在调试环境下也是。所以我跟苹果都不推荐你使用它。具体信息看第36节。
每个对象创建时引用计数最少为1。如果想要对象存活,就调用retain
方法。当某部分代码不再需要这个对象时就调用release
或者autorelease
。当引用计数为0时,对象被释放,这意味着这块内存被标为可复用。一旦对象被释放,那么该对象的任何引用都是无效的。
图5.1展示了一个对象的创建,持有和两次释放。
Figure 5.1 一个对象的整个生命周期内它的引用计数的增加和减少
在一个应用的生命周期内,有许多对象被创建。这些对象往往跟另一个对象有所关联。例如,有一个代表人的对象,它会对一个用字符串表示的人名进行引用,也可能有别的引用,例如一个代表它朋友的结合,这些构成了一个对象表。如果一个对象对另一个对象持有一个强引用,那么前者持有后者。这个意思是当某个对象对其余对象有使用的意图时,就可以通过持有的方式保证后者被释放。当它不在需要后者时,再对后者进行释放。
图5.2的对象表中,ObjectA
同时被ObjectB
和ObjectC
持有。当ObjectB
和ObjectC
不在持有ObjectA
并且ObjectA
引用计数为0时,ObjectA
会被释放。ObjectB
和ObjectC
被其余对象持有,而其余对象又被别的对象持有。如果你查找整个对象表,你会发现一个根对象。在Mac中,根对象是NSApplication
;在iOS中,根对象是UIApplication
。这两个对象都是程序启动时创建的一个单例对象。
Figure 5.2 对象表展示一个对象被释放之前它的引用的释放
下面的代码将帮助你理解上图:
|
|
如前面展示的一样,上述代码在ARC下无法编译通过。因为显式调用了release
方法。在Objective-C
中,调用alloc
方法返回的对象由调用者持有。也就是说,调用者通过 alloc
方法表达了想让对象存在的想法。但是有一点需要注意,这时它的引用计数并不一定是1。它有可能比1大,因为alloc
或者initWithInt:
的实现里面有别的对象对他有引用。这样,这个对象的引用计数就最少为1了。你应该这样理解引用计数这个概念。你不应该认为引用计数是几,只应该说清楚引用计数增加或者减少。
然后number
对象被添加进数组。数组通过addObject:
方法一直保持number
的引用。
这时,number
对象的引用计数最少是2。然后,这段代码不再需要number
变量,所以释放它。这时引用计数最少是1。这时,number
变量不再可以安全的使用。它调用release
的意思是不再能保证所指对象是否存活了。当然,在这个例子中,我们很明显可以知道它在调用了release
之后仍然是存活的,因为数组还在引用它。但是不要假定一个对象存活,就是不要像下面这样写代码:
|
|
这样的代码即使在这个环境下可以运行,它也不是好的做法。不论出现任何原因导致number
对象的引用计数为0,然后被释放,那么当你调用NSLog
的时候,程序就可能会崩溃掉了。这里为什么说是可能呢?因为对象释放之后,只是将内存放回可用内存池。如果在你调用NSLog
时,内存还没有被覆盖,那么该对象仍然存在,就不会发生崩溃。因此,过早释放对象会造成难以调试的问题。
为了减少这种对象已经被释放的潜在风险,你经常能看到在release
之后将对象设为nil的代码。这能确保不会通过指针调用一个无效的对象,这种指针被称为悬垂指针。例如,像下面这样:
|
|
属性存取器的内存管理
正像前面说的那样,对象链接在一起构成了对象表。上面例子中的数组通过对对象进行retain
操作持有它们。同样,其它对象也可以使用属性持有其它对象,并通过存取方法去获得或者设置实例变量。如果属性是一个强引用,则设置的属性值会被保留。一个叫做foo
的属性的,它有一个叫做_foo
的实例变量,它的setter
方法像下面这样:
|
|
保存新值,释放旧值。然后更新实例变量指向新的值。这个命令是重要的。如果旧值在新值保留前释放并且这两个值是相同的,这意味着这个对象可能会被过早的释放。后面的retain
操作也无法使这个对象存活,然后这个实例变量将会变成一个悬垂指针。
自动释放池
在Objective-C
的引用计数中有一个重要结构,那就是自动释放池。调用release
的会直接减少引用计数(可能导致对象直接释放),你也可以使用autorelease
,它会在之后执行释放操作,通常是在下一次循环事件时递减,不过也可能更早(具体看第34节)。
这个功能是非常有用的,特别是当一个方法返回一个对象时。在这种情况下,我们并不想另调用者手动保存其值。例如,下面的代码:
|
|
(NSString*)stringValue {
NSString *str = [[NSString alloc] initWithFormat:@”I am this: %@”, self];
return [str autorelease];
}12现在这个方法返回时,对象一定存活。所以这个对象可以这样使用了:
NSString *str = [self stringValue];
NSLog(@”The string is: %@”, str);
_instanceVariable = [[self stringValue] retain];
// …
[_instanceVariable release];
```
所以autorelease
可以延长对象的生命周期,使其跨越方法调用边界之后仍然存在。
循环引用
使用引用计数时常遇到的一个场景是循环应用,它发生在多个对象相互引用的时候。它会导致内存泄露,因为没有办法调用到这些循环引用的对象,并将其引用计数设为0。在循环引用中,每个对象都会被最少一个对象持有。在图5.3中,每一个对象都有另外两个对象的引用。在这个循环中,所有对象的引用计数都是1。
Figure 5.3 一个循环引用的对象表
在垃圾回收中,这种情况会被标记为孤岛。这种情况下,垃圾回收器会将三个对象都释放掉。很遗憾在Objective-C
的引用计数中不存在这种做法。这个问题的常用做法是使用弱引用(看第33节)或者将这些对象中的某一个放弃持有其它对象。上述的两种做法都可以打破循环引用,这样内存泄露就不存在了。
小结
- 以引用计数方式进行内存管理是基于计数器进行增加和减少的。一个对象创建后,它的引用计数最少为1。如果引用计数为正,则对象存活。如果引用计数为0,则对象被释放。
- 在对象的整个生命周期中,一个对象通过引用来保留和释放其他对象。保留和释放会增加和减少引用计数。
ARC使引用计数更加简单
理解引用计数概念是很简单的(看第29节),但是retain
和release
出现的场景很是频繁。所以Clang
编译器搞了一个静态解析器,用于指出引用计数出现问题的地方。例如,考虑下面代码片段的引用计数:
|
|
这段代码有一个内存泄露,因为在判断语句的结尾,message
对象没有释放。因为出了条件语句后,就没办法引用它了,对象就泄露了。判断内存泄露的规则很简单。它调用了NSString
的alloc
方法生成了一个对象,使其引用计数最少为1。但是它没有释放。这些规则很容易表达,电脑可以轻易使用这些规则并告诉我们哪个对象发生了泄露。这就是静态编译器要做的事情。
静态分析还有更深层次的用途。因为它可以告诉你哪个地方发生了内存泄露,所以它也可以在需要的地方添加retain
或者release
,是吧?ARC就是由这个概念诞生的。ARC的就像它名字说的那样:使引用计数自动化。所以上面的代码会在判断语句结束的地方自动加上release
操作,自动添加后的代码是这样的:
|
|
需要记住的是即使使用了ARC,引用计数仍是在执行的。只不过添加retain
或者release
的操作是ARC做的。除了为方法返回的对象处理内存管理语义ARC还做了更多的事,稍后你将看到。不过这些功能,都是基于核心内存管理语义构建的,这套标准适用于整个Objective-C
。
由于ARC会自动的调用retains, releases, autoreleases
,所以你在ARC下直接调用内存管理方法是不合法的。尤其是,你不能调用下面这些方法:
- retain
- release
- autorelease
- dealloc
在ARC下,你直接调用上述方法的任意一个都会导致编译错误,因为你这样做会导致ARC无法正常工作。你必须相信ARC可以处理好这些,这会使某些开发者不是很放心。
实际上,ARC并没有通过正常的Objective-C
派发机制去调用这些方法,它直接调用了底层的C函数。这是一种优化,因为retain
和release
方法调用的是很频繁的,并且也可以减少CPU的工作量。例如,retain
是等价于objc_retain
的。这就是为什么覆写retain, release, autorelease
这些方法是非法的,因为它们并不是直接调用的。对于本章节的其余部分,我仍将讲述与底层C函数等价的Objective-C
方法。这对那些使用过手动管理引用计数的人更友好。
ARC中的方法命名规则
在Objective-C
中,将内存管理语义通过方法名表现出来是惯例,而ARC则将其确定为硬性规则。规则是很简单的并且跟方法名有关联的。一个返回对象的方法,如果方法以下列名词开头,它的所有权归属调用者:
- alloc
- new
- copy
- mutableCopy
归属调用者的意思是调用那四种方法的调用者需要管理返回值的释放。这是说,返回对象有一个正的引用计数,调用者需要去平衡这一次引用计数。如果有别的对象对其进行了保存或者进行了autorelease
,那么它的引用计数会大于1,这就是为什么说retainCount
方法没有什么用的原因(看第36节)。
任意别的方法名都代表返回对象的所有权不归属于调用者。这种情况下,对象将自动调用autorelease
,这样返回对象的值就可以在跨越边界调用后仍旧有效。如果想确保对象仍然存活,可以调用retain
保留它。
ARC自动处理所有需要操作的内存管理规则,包括代码返回值的autorelease
,就像下面代码展示的那样:
|
|
ARC通过名称来约定内存管理的规则,新手往往感觉不可思议。很少有其他语言像Objective-C
一样把命名看的这么重要。适应这种模式对于成为一个好的Objective-C
开发者是重要的。在这个过程中,ARC帮助你做了大量的工作。
ARC除了为你添加retain、release
这些方法,还有一些其他好处。它也会做一些手动难以完成或者不能完成的操作。例如,ARC能互相抵消retain、release、autorelease
的互相操作。如果某个对象多次进行retain
和release
,ARC可以成对的移除它们。
ARC也包含有运行期组件。这些优化发生在运行时,这些就是我们为什么应该在ARC下进行开发。前面提到某个对象需要在返回时进行autorelease
。但是调用者需要代码存活就会对它进行retain
操作,就像下面这个情况:
|
|
调用personWithName:
返回了一个自动释放的EOCPerson
对象。但是编译器也需要去给那个实例变量添加retain
操作,因为它是一个强引用。因此上面的代码等价于下面的手动代码:
|
|
你会发现这里的autorelease
和retain
都是多余的。去除它们两个可以获得更好的性能收益。但是在ARC下的代码需要考虑向后兼容性,即需要去兼容非ARC的代码。ARC可以移除autorelease
这个概念,并且指定所有的方法返回的对象的引用计数都加1。但是,它需要向后兼容。
但是ARC可以在运行时检测到这种多余的行为,即autorelease
操作后面跟retain
。当一个对象自动释放时,它会调用一个特殊函数,而不是对象的autorelease
方法,它叫做objc_autoreleaseReturnValue
。这个函数会检查当前函数返回后的那段代码。如果它发现在返回对象后会对对象进行retain
操作,它会设置全局数据结构(取决于处理器)中的一个标志,而不执行release
操作。同样,对一个自动释放对象进行retain
的代码,也不会调用retain
,而是执行一个叫做objc_retainAutoreleasedReturnValue
的方法。这个方法会检查标志是否存在,如果存在,就不执行retain
。对标志进行设置和检查是快过使用autorelease
和retain
的。
下面的代码展示了ARC是如何使用特殊函数进行优化的:
|
|
为了求得最佳优化,特殊函数在不同处理器都有不同表现。下面的伪代码展示了大概流程:
|
|
objc_autoreleaseReturnValue
函数如何检测需要保留对象呢。这取决于处理器。只有编译器的作者知道怎么实现它的,因为它需要使用检查机器码。除了编译器的作者谁知道调用的方法是怎么实现的。
这只是编译器在运行期的一种优化。所以使用ARC是一个好的建议。编译器和运行时日渐成熟,我相信会有更多的优化技术的出现。
变量的内存管理语义
ARC也可以处理本地变量和实例变量的内存管理。通常每个变量都对对象持有强引用。这点是非常重要的,特别是实例变量,因为对象相同的代码,在手动引用计数和自动引用计数都是不一样的。例如,考虑下面的代码:
|
|
在手动管理引用计数下,_object
实例变量不会自动保留这个值,但是在ARC下会。因此,在ARC下编译这个代码,方法会变成这样:
|
|
当然,这种情况下,retain
和release
都可以取消掉。所以ARC这样做了,就像转换前的代码一样。但是当它发生在设置setter
方法时。如果不使用ARC,你可能会这样写:
|
|
但是这样做有一个问题。那就是如果设置的新值和已有的值相同会发生什么?如果这个对象仅有一个引用,那么release
会导致这个对象的引用计数变为0并且释放这个对象。后面的retain
操作将会导致应用程序崩溃。使用ARC则不可能发生这种错误。ARC下等价的setter
方法如下:
|
|
ARC执行一种安全的设置变量的方法,先保存新值,然后释放旧值,最后设置实例变量的值。你可能在MRC下已经明白这个问题了并且能正确编写,但是在ARC下你无需考虑这种类似的边界情况。
本地和实例变量可以通过下面这些修饰符进行语义改变:
- __strong 默认修饰符,这种情况下,变量会被保存。
- __unsafe_unretained 这个值不会被保留,这样是不安全的,因为你再次使用它时,可能它已经被释放了。
- __weak 这个值也不会被保留,但它是安全的,因为它会在变量为空时,自动设置为nil。
- __autoreleasing 把对象按引用传递时始终它,当返回时它会被释放。
例如,想令变量与不使用ARC时一样,可以这样做:
|
|
在这种情况下,当设置实例变量时,对象不会被保留。只有在使用新版运行时库(Mac OS X 10.7、iOS 5.0)的时候,weak
修饰符才会自动将实例变量置为nil,因为它需要依赖依稀新的特性。
当我们在块(看第40节)中使用本地变量时,通常使用标示符去打破循环引用。块自动的引用所有它捕获的对象,这样加入那些对象中的某一个也保留有块,就会造成循环引用。使用__weak
修饰变量可以打破循环应用:
|
|
ARC清理实例变量
就像上面展示的一样,ARC会处理实例变量的内存管理。如果要这样做,ARC就需要在释放时期生成清理的代码。凡是使用强引用的变量,ARC都会在dealloc
方法中释放它。而在MRC下,需要你自己实现dealloc
方法就像这样:
|
|
使用了ARC,dealloc
方法就不需要这样写了;因为ARC会借用Objective-C++
的一项功能来实现清理。在释放时,Objective-C++
对象会调用所有C++对象的析构函数。当编译器发现对象包含C++
对象时,它会生成一个叫做.cxx_destruct
的方法。ARC借助此方法生成清理内存所有的代码。
但是,如果有不是Objective-C
的对象,你仍然需要去手动清理,例如CoreFoundation
对象,或者是malloc()
分配的堆内存。但是你不需要像之前那样去调用父类的dealloc
方法。在ARC下不能直接调用dealloc
方法。所以ARC会自动在.cxx_destruct
中生成并运行代码,也会在生成的代码中自动调用父类的dealloc
方法。在ARC下,一个dealloc
方法大概是这样的:
|
|
由于ARC会生成这个方法,所以一般不需要实现dealloc
方法。这可以减少项目源码的大小,并减少模板代码。
覆写内存管理方法
在非ARC时代,是可以覆写内存管理方法的。例如,一个单例类通常会覆写release
方法,使其什么也不做,因为单例类不需要释放啊。在ARC就不能这样,因为这会影响ARC对对象声明周期的分析。而且,由于不能调用和覆写这些方法,所以ARC就可以不使用Objective-C
的消息派发系统从而对retain, release, autorelease
方法进行优化。相应的,可以直接调用运行期的C函数。ARC可以对这些进行优化例如刚才说的对一个返回对象进行autorelease
操作,然后接着又进行retain
操作。
小结
- ARC使开发者不用担心内存管理。使用ARC也可以减少类的模板代码。
- ARC管理对象生命周期的办法就是在合适地方插入
retain
或者release
。ARC下变量可以通过修饰符去改变内存管理语义,MRC下只能手动进行retain
或者release
。 - 方法名字已经指出了返回对象的内存管理语义。ARC将这些规则确定为必须遵守的规则。
- ARC仅能处理
Objective-C
对象。特别是不能处理CoreFoundation
的对象,它们必须使用CFRetain/CFRelease
去处理。
在deaclloc中释放引用并清除监听状态
一个对象走完生命周期后会被释放,那个释放的入口就是dealloc
方法。在对象的整个生命周期中,释放只会被调用一次,当对象的引用计数为0时。不过什么时候调用就不知道了。也就是说,你可能通过你手动调用retain
或release
大概推测出它什么时候调用,但其实这是由系统决定的,它会在你不知道的时候进行释放。你永远不该调用dealloc
本身。系统会在运行时在正确时间调用它。而且dealloc
被调用后,对象都不再有效,后面的方法也是无效的。
那么你应该在dealloc
中做什么呢?主要要做的应该是释放对象的所有持有。这个意思是释放所有的Objective-C
对象,ARC会自动帮你添加进dealloc
方法,通过自动生成.cxx_destrucr
(看第30节)方法。任何非Objective-C
也应该在这里释放。例如,CoreFoundation
的对象需要释放,因为它是纯C的API。
另一个需要在dealloc
中做的事情是清除所有的观察者行为。如果有对象注册了通知者,那么这里是一个移除通知的好地方。这样就不会向这个对象发送通知了,否则会导致应用程序崩溃。
一个dealloc
方法大概像这样:
|
|
注意当你使用MRC而不是ARC时,你应该在这些方法后面调用[super dealloc]
。ARC会自动在后面调用,这也是一个ARC比MRC安全简单的原因。并且使用MRC,你不得不讲每个需要释放的Objective-C
对象添加进来。
即便如此,你不应该在这里释放开销较大或者系统的稀缺资源。例如文件描述符,套接字,大块内存。你不应该依赖dealloc
方法去释放这些对象,因为有时候别的东西也会持有这些对象。这样会造成你不需要某个系统稀缺资源,但是却还在持有它,这是不合理的。通常的做法是当程序不再使用它时,实现别的办法进行释放。这样资源的声明周期就是明确的了。
比如一个管理套接字链接的对象,它需要有清理的方法。或许是一个数据库链接。这样类的接口大概是这样的:
|
|
当需要使用时,可以调用open
方法打开链接;当链接结束时,可以调用close
方法。close
方法一定要在链接对象释放前调用;否则,会被认为是一个项目错误,就像你不得不使用retain
和release
去平衡引用计数一样。
另一个在别的清理方法释放资源的理由是实际上对象的dealloc
方法不一定会被调用。边界情况下,当程序意外退出时,对象可能仍然存在。这些对象没有接收到释放信息。相应的,当系统终止后,它们占用的资源也会返回给系统。所以不调用dealloc
方法也是一种优化。这也说明不是每个对象都会调用释放方法的。在Mac OS X和iOS中都有一个应用的协议方法,当程序结束时会调用。这个方法可以被用来清理某些必须要清理的对象。
在Mac OS X中,程序结束会调用的协议方法是:
|
|
在iOS中,程序结束会调用的协议方法是:
|
|
如果对象管理着某些资源,那么在dealloc
中也应该调用它们的清理方法,以减少意外情况。如果有意外情况发生,那么有一个好办法是,输出一句信息去指明程序发生了一个错误。这是一个编程错误,因为这个关闭方法需要在对象释放前调用;否则,这个方法就不会有效果了。输出信息会警告开发者改正这个问题。在dealloc
中去关闭资源依然是一个避免内存泄漏的好习惯。下面有一个这样的例子:
|
|
如果关闭方法没有调用,那么相比输出一个错误,你应该去抛出一个异常指出程序发生了一个严重的错误。另外就是要避免在dealloc
方法中调用别的对象。在上面的例子中,在dealloc
中调用了一个方法。但那是一个特殊情况:去查明程序错误。无论在这里调用什么方法都不太合适,因为这里的对象已经接近尾声了。如果别的方法还会异步执行任务或者调用它们自己的方法,等到对象执行完任务,对象早被释放了。这会导致很多问题并且可能导致程序崩溃,因为它们会回调告诉对象任务执行完了。如果对象早已被释放,那么就会发生错误。
另外,调用释放方法的线程会进行最终释放,使所有对象的引用计数为0。有些方法需要运行在特定的线程,例如主线程。如果在dealloc
中调用它们,无法保证它们运行在正确的线程。没有什么常规代码可以保证它们安全的运行在正确的线程,因为对象已经处于释放状态了,并且运行时已经对内部的数据结构进行释放标示了。
也应该避免在dealloc
中调用属性存取器,因为它们可以被覆盖并且去执行一些在释放期不安全的操作。比如,某个对象可能通过KVO监听属性,并且监听者想去做一些事情,例如试图保留对象,或使用这个将被回收的对象。这样做会导致在运行期出现一些莫名错误,并可能导致程序崩溃。
小结
dealloc
方法里面只应该被用来释放对象以及取消注册,例如KVO或者NSNotificationCenter通知。- 如果一个对象持有系统资源,例如文件标示符,那么应该有一个方法去释放资源。当资源使用结束时,类的使用者应该调用关闭方法。
- 避免在
dealloc
方法中调用执行异步操作的方法或者只能正常状态执行的方法。
小心异常安全代码的内存管理
现代语言中,异常是一个常见的语言功能。C中不存在异常,但C++
和Objective-C
中存在异常。实际上,在当前的运行时系统中,C++
和Objective-C
的异常都是通用的,这意味着一个语言抛出的异常可以被另一个语言捕获。
Objective-C
的错误模型表示只有发生致命错误时才应该使用异常,你可能仍需要错误代码去捕获并处理异常。比如使用Objective-C++
或者不受你控制的第三方库代码时,应该捕获异常。而且,有些系统库仍在使用异常,仿佛回到了异常频繁使用的年代。例如,如果你想去取消一个尚未注册的观察者,那么KVO就会抛出一个异常。
当异常发生时会带来一个内存管理问题。在try
块里面,如果保留了一个对象,然后再对象释放前抛出了异常,那么对象将会发生泄漏除非在catch
块中进行处理。C++的析构函数由Objective-C
的异常处理来运行。这对于C++是非常重要的,因为这会缩短对象的声明周期,所以抛出异常时要调用析构函数;否则,对象内存将会泄露,特别是别的系统资源,例如文件权柄,是更容易泄露的。
异常处理机制会自动销毁对象,不过在MRC环境下处理对象销毁有些麻烦。考虑下面的Objective-C
代码,它是在MRC下:
|
|
乍看起来,它似乎是正确的。但是如果doSomethingThatMayThrow
方法抛出异常呢?下面一行的释放代码将不会运行,因为异常会直接跳入catch
块。所以当抛出异常时,这个对象将会泄露。这不是一个好主意。使用@finally
块可以解决这个问题,不论是否抛出异常,@finally
块一定会运行也只会运行一次。例如,代码将会转换成这样:
|
|
注意对象是在@try
块之外声明的,因为需要在@finally
块中使用它。如果所有的对象都需要释放那是非常单调的。而且,如果这里的逻辑更加复杂,@try
块内状态更多,那么是非常容易忽略释放的,从而导致潜在的泄露风险。如果一个稀缺资源的对象泄露,例如文件标示符或者数据库连接,那么这个泄露就是灾难性的,因为最后应用程序占用的所有的系统资源都不会释放。
在ARC下,这种情况会更加严重。下面是与之等价的ARC环境下的代码:
|
|
现在问题更严重了;你不能将释放放在@finally
的块内了,因为不能调用release
了。你可能会认为ARC已经处理了这种情况。默认情况下并没有做;因为这样做需要给对象添加大量模板代码,当有异常抛出时追踪对象清理。当抛出异常时,这段代码会严重影响性能。这段额外的代码也会增加应用程序的大小。总之这不是一个好的建议。
虽然默认状态下未开启,但是ARC支持这种异常安全机制。你可以在编译器中使用-fobjc-arc-exceptions
开启。其默认不开启的原因是Objective-C
定义异常应该只在该异常会导致应用重大错误时抛出(看第21节)。因此,如果应用程序将要终结,那么潜在的内存泄露就无所谓了。所以在应用程序将要终结时添加安全代码是没有什么意义的。
当编译器处于Objective-C++
模式时,会自动打开这个-fobjc-arc-exceptions
标示。因为C++的异常处理代码在ARC下与Objective-C
的额外异常安全代码类似,所以在ARC下自动开启这些代码对性能影响并不大。而且,C++代码用的太多,Objective-C
开发者可能也想使用异常处理。
如果你在MRC下开发并且一定要捕捉异常,那么一定记得正确的清理你的代码。如果你在ARC下开发并且一定要捕捉异常,那么你需要开启-fobjc-arc-exceptions
标示符。但最重要的是,如果你发现你使用了大量的异常捕捉,那么考虑使用NSError-style
重构代替,如第21节展示的一样。
小结
- 如果捕捉了异常,一定要将在
@try
块中的代码处理干净。 - 默认情况下,在异常发生时ARC并不会处理干净代码。你可以通过一个编译器标示符开启它,但这会使得代码包变大和运行时花费变大。
使用弱引用避免循环引用
在对象表中会有一种典型的情况,那就是每个对象都持有对方的引用。当它发生在引用计数模型下时,例如Objective-C
的内存管理模型,那么某个地方肯定会发生内存泄露,因为最后没有对象持有循环的对象的引用。因此,没有对象可以访问循环引用,循环引用中的对象也不会被释放,因为它们相互持有保证对方的存活。
在最简单的循环引用中,两个对象互相持有。图5.4展示了一个例子。
Figure 5.4 两个互相持有强引用的对象构成了循环引用。
循环引用是非常容易理解的并且也可以通过看代码找到:
|
|
从代码可以轻易的看出来这个潜在的循环引用;如果将EOCClassA
中属性设置为EOCClassB
的实例变量,将EOCClassB
的属性设置为EOCClassA
的实例变量,那么就会发生像图5.4那样的循环引用。
循环引用的结果肯定是内存泄露。当对循环引用中对象的最后一个引用移除时,就会发生内存泄露。这意味着没有对象可以访问它们。在图5.5当ObjectB
对象的最后一个引用被移除时,一个涉及四个对象的复杂循环引用就产生了。
Figure 5.5 当对象表中的循环引用的对象的最后引用被移除,循环引用就会发生内存泄露
在MAC OS X上有个选项可以使用垃圾回收机制,垃圾回收机制会找到循环引用的地方并且清理掉没有任何引用的循环引用。但是垃圾回收机制在MAC OS X 10.8被废弃了,在iOS上更是没存在过。因此,在写代码时,需要注意这个循环引用问题并确保它不会发生。
最好的避免循环引用的办法是使用弱引用。这样引用的一方总是非持有关系。通过unsafe_unretained
特质也能达到这个效果。下面的例子中使用了这个特质:
|
|
在这里,EOCClassB
的other
属性不会持有EOCClassA
实例变量。这个叫做unsafe_unretained
的特质指明属性是不安全的并且不会保留对象。如果设置的对象已经被释放了,那么调用它会导致程序崩溃。因为该属性不会保留对象,所以对象有可能被释放。
unsafe_unretained
特质与assign
特质语义相同。但是,assign
通常用在基础类型(int, float, structs, etc.),unsafe_unretained
用于对象类型。这个特质本身就表明对象可能无法安全使用。
不过ARC给Objective-C
打来了一项功能那就是可以使用安全的弱引用:一个叫做weak
的属性特质,它的作用跟unsafe_unretained
相同。但是当对象释放时,它会自动将属性设置为nil。在上面的例子中,可以这样修改EOCClassB
的属性:
|
|
图5.6展示了unsafe_unretained
和weak
的不同之处。
Figure 5.6 当属性指向的对象释放时,unsafe_unretained
和weak
的不同之处
当使用unsafe_unretained
时,EOCClassB
的other
属性仍然指向一个已经释放的对象;当使用weak
时,EOCClassB
的other
属性将会指向nil。
但是,使用weak
特质不应成为你偷懒的借口。在上面的例子中,当EOCClassA
的对象释放后,如果EOCClassB
属性仍然指向它,那是一个编程错误。如果发生了这种事情,那就是一个bug。你应该确保这种情况不会发生。但是使用weak
特质比使用unsafe_unretained
特质安全是肯定的。相对比应用崩溃,应用可能更能接受展示错误的数据。这么做无疑对终端用户更好。但是,在所指对象销毁后,仍使用弱引用,那仍是一个bug。例如有一个用户界面,它有一个属性持有数据源对象,用户界面需要展示数据。假如这样一个属性是弱引用。如果数据源在元素展示之前被释放,那么弱引用意味着虽然不会崩溃但用户界面不会显示任何数据。
通常的规则是如果你不持有一个对象,那么就不要保留它。但是有个例外,那就是集合,集合类虽然不直接持有内容,但它要它所属的对象来保留这些元素。有一个例子,对象的引用会指向自己并不拥有的对象,比如委托模式。
小结
- 可以通过使用
weak
避免循环引用。 - 弱引用可能会也可能不会自动清空。自动清空是由ARC带来的一个在运行期实现的新功能。自动清空的弱引用是安全的,它永远不会引用一个已经释放的对象。
使用自动释放池降低高峰内存值
Objective-C
对象的存活是受到引用计数(看第29节)控制的。Objective-C
的引用计数体系中的一个功能被称作自动释放吃。释放一个对象要么通过调用releas
直接减少引用计数,要么通过调用autorelease
添加一个自动释放池。一个自动释放池实际上是一个集合对象,它会在将来某个时刻进行释放。当一个自动释放池释放时,自动释放池内所有的对象立即发送release
消息。
创建一个自动释放池的语法如下:
|
|
如果这里没有自动自释放池,当对象发送autorelease
消息时,你将会看下类似下面的输出:
|
|
但是你通常不需要担心这个事。因为一个运行在Mac OS X或者iOS上的应用程序,它们都是处于Cocoa(或者Cocoa Touch)环境。系统都会给你创建一些线程,例如主线程或者GCD机制中的线程,每个线程都会有一个自动释放池,每次事件循环它们都会被清空。因此,你不需要创建自动释放池块。通常,你在应用程序的入口,即main
函数那里会看到一个自动释放池包裹了整个应用程序。例如,一个iOS应用的main
函数通常是这样的:
|
|
从技术上讲,这个自动释放池不是必须的。只有在程序中止的时候,整个操作系统才需要释放所有的内存。但是没有它,UIApplicationMain
自动释放的对象就没有自动释放池可以放置了,然后会输出一个警告信息。所以这个自动释放池就是最外围用来捕捉自动释放对象的池。
大括号定义了自动释放池的作用范围。在第一个大括号创建自动释放池,在超出作用域后自动释放。因此任何在这个池中的对象都会在最后发送release
消息。自动释放池可以嵌套。当一个对象是自动释放时,它会自动添加进最近的自动释放池。例如:
|
|
在前面那个例子中,有两个使用工厂方法创建的对象,它们会自动释放(看第30节)。那个字符串对象将会被加入外层的自动释放池,数值对象将会被加入内层的释放池。嵌套自动释放池可以带来优势,那就是可以控制应用程序的峰值内存不会过高。
考虑下面的代码:
|
|
如果doSomethingWithInt:
方法创建了很多临时对象,它们将会加入自动释放池。例如,那些对象可能是字符串。即使你不在后面使用它们,这些对象仍旧存活,因为它们在自动释放池内,准备释放并回收。但是自动释放池不到下一次事件循环不会释放。这意味着在这次事件循环中,会有越来越多的对象被创建并加入自动释放池。直到最后,事件循环结束,它才会被释放。但是这样在事件循环期间,应用程序的内存肯定会暴增并在最后释放的时候内存暴减。
这种情况是不好的,特别是如果这个事件循环的长度不固定,取决于用户输入。例如,下面从数据库获取集合对象。代码可能是这样的:
|
|
这个EOCPerson
类可能会创建非常多的临时对象,就像刚才那个例子。如果数据库记录是庞大的,那么就会有大量的临时对象一直存活,而它们本应被早收回的。可以在这里增加一个自动释放池帮助提前回收。如果在循环内部包裹一个自动释放池块,那么任何自动释放的对象都是在超出这个池作用域时释放而不是主线程的自动释放池。例如:
|
|
添加了新的自动释放池后,应用程序的内存峰值会一直持续在稳定的水平。内存峰值是指应用程序某个时间段内的最大使用内存。添加自动释放池可以降低这个峰值,因为它会在块结束时释放某些对象。这些临时对象就是需要释放的一部分。
可以把自动释放池比喻成栈。当一个自动释放池被创建,它被推入栈;当它释放时,它从栈中弹出。当一个对象是自动释放时,它自动被放入栈顶的自动释放池。
是否添加自动释放池优化取决于你的应用程序。首先监视内存的峰值然后再决定是否需要使用自动释放池。虽然自动释放池消耗不大,但还是有消耗的,所以如果不需要使用,就避免创建自动释放池。
如果你是一个在ARC出现之前的Objective-C
程序员,那么你还记得老式的语法,即使用一个叫做NSAutoreleasePool
的对象。这是一个特殊对象,它不同于正常对象,它设计出来就是代表自动释放池的,就像新的块语法一样。这个不是每次for循环都会释放的,它是一个稍重的自动释放池,它通常用在偶尔需要释放的内容上,像这样:
|
|
不过这种代码风格不再需要了。使用新的语法,ARC带来的更轻量级的自动释放池。所以如果你有代码需要在循环内部释放,你可以使用自动释放池块去包括那部分代码,这样每次循环都会自动创建并清空自动释放池。
@autoreleasepool
语法还有一个好处就是它有自己的作用域,这样可以帮助你避免使用那些已经被自动释放的对象。例如,考虑下面的代码风格:
|
|
这略微夸大了问题,但是它确实存在。useObject:
可能会调用一个释放过的对象。但是,相同点在新样式是这样的:
|
|
这样的代码是无法通过编译的,因为对象变量在作用域外是无效的,所以useObject:
不能使用它。
小结
- 自动释放池排在栈中,当对象发送
autorelease
消息时,自动将他加入栈顶层的自动释放池。 - 正确的使用自动释放池可以帮助降低应用程序的内存峰值。
- 现在的自动释放池使用新的
@autoreleasepool
语法。
使用僵尸对象调试内存管理问题
一直以来,调式内存问题都是麻烦的。向一个已经释放的对象发送消息是不安全的,这点跟我们所想是一样的。但是有时候它会正常工作,有时候它不会。这取决于那块内存是否已经被覆写了。这块内存是否被用来做其它事情呢,又无法确定,所以偶尔会发生崩溃。有时,那块内存仅有一部分被覆写,所以还有部分二进制有效。还有一种可能,就是覆写这块内存区域的是一个有效对象。这时,运行时会把消息传递给新对象,它能或者不能响应这个消息。如果能响应消息,应用不会崩溃,但是你会想为什么收到消息的不是想象中的那个呢?如果它不能响应消息,那么应用依旧会崩溃。
幸运的是,Cocoa的僵尸对象功能可以处理这些情况。当在调试功能打开时,运行时会将所有被销毁实例转换成一个特殊的僵尸对象而不是销毁它们。这种对象的内存不会被回收,因为也就不会被覆写。当僵尸对象收到消息时,它会抛出一个异常,并说明发送消息的对象以及转换僵尸对象前的对象。使用僵尸对象是调式内存管理问题的最佳办法。
这项功能是通过设置NSZombieEnabled
环境变量为YES使用的。例如,如果你使用脚本并且在Mac OS X上运行它,你可以这样写:
|
|
当给僵尸对象发送消息时,将会在控制台输出一条消息,并且应用程序会终止。这条消息看起来是这样的:
|
|
你也可以在Xcode中设置这个环境变量,当你从Xcode运行时,它会自动读取。为了这样做,你需要编译应用程序的scheme
,选择Run
配置,然后点击Diagnostics
标签,最后打开Enable Zombie Objects
。图5.7展示了Xcode的设置界面,以及打开僵尸对象的选项。
Figure 5.7 在Xcode的scheme编辑器中打开僵尸对象
那么僵尸对象是如何工作的呢?它实现在Objective-C
运行期、Foundation
、CoreFoundation
框架的底层。当一个对象被释放时,如果这个功能开启了,就会多一步操作。多出来的一步就是将对象转化为一个僵尸对象而不是直接释放。
去看多出来的一步做了什么,考虑下面的代码:
|
|
代码使用MRC是的更容易看清对象如何转化为僵尸对象。ARC会使obj
对象尽可能的长时间存活,意味着这个简单的例子不会转化为僵尸对象。这意思不是说在ARC下对象不会转化为僵尸对象。使用ARC这个内存bug依然存在,只不过需要更复杂的代码才能表现出来。
上述例子中有一个函数用来输出给定对象的类和父类的名字。代码使用了object_getClass()
方法,这是一个运行时函数,而不是给类发送Objective-C
消息。如果那个对象是一个僵尸对象,发送任何Objective-C
消息都会导致打印错误信息,并使得应用崩溃。上述代码的输出像下面这样:
|
|
对象的类从EOCClass
变为_NSZombie_EOCClass
了。但是这个类从哪里来的呢?代码里面没有定义这个类啊。而且,在启用僵尸对象功能后,编译器给每个类创建一个额外的类那效率也太低了。这个类是在运行时第一个EOCClass
对象转变为僵尸对象时生成的。它使用了运行时函数,用来操作类列表。
僵尸类是从一个叫做_NSZombie_
的模板类复制的。僵尸类并不做太多的事请,它仅仅是作为一个标记。你将会看到它如何做为一个标记的。首先看下面的伪代码,它展示了如何如何根据需要创建僵尸类并将对象转化为僵尸对象。
|
|
这个过程发生在NSObject
对象的dealloc
方法中。当NSZombieEnabled
环境开启时,运行时会交换(看第13节)dealloc
方法的实现和前面的代码实现。在这个过程的最后,对象的类已经变为_NSZombie_OriginalClass
类了,其中OriginalClass
是它本来类的类名。
重要的是,这个对象的内存并没有被释放。因此,这块内存不会被再次使用。尽管它发生了内存泄露,但它是一个调试工具并且不会将其发布在正式包中,所以不要在意这个。
但是系统为什么会为每一个僵尸对象创建一个新类呢?这样做是因为当给僵尸对象发送消息时,可以确定它原来的类是什么。如果所有对象的类都是_NSZombie_
,那么原来类的名字就不知道了。通过运行时的objc_duplicateClass()
函数创建一个新类,拷贝整个僵尸类然后给一个新的名字。它的父类,实例变量,方法都跟原来一模一样。另一个方法是通过继承_NSZombie_
类创建一个新类而不是拷贝它。但是使用相应函数时,它的效率没有直接拷贝的高。
僵尸类的作用体现在消息转发机制(看第12节)中。_NSZombie_
类不实现任何方法。它也不需要任何父类,因此它自身就是一个根类,就像NSObject
一样,只有一个叫做isa的实例变量,它是所有Objective-C
根类都要有的。这个轻量级的类不实现任何方法,所以所有发送给它的消息都会走完整的消息转发机制。
消息转发机制的核心是___forwarding___
,你可能在调式的时候通过栈回溯看到过它。它要做的第一件事就是检查接受消息的类的名字。如果名字前缀是_NSZombie_
,那么就肯定它是一个僵尸对象,那么就会特殊处理。应用程序会在这里终止,然后打印一条消息指出接受消息的类的类型。这时就可以看出来在僵尸类命中加入原类名的好处了。将_NSZombie_
从僵尸类名中移除就是原来类的名字了。下面的伪代码展示了发生的事情:
|
|
尝试给开头的那个例子进行扩展,给将是对象发送消息:
|
|
如果僵尸对象开启了,你将会看到下面的输出信息:
|
|
如你所见,上面的信息清楚的展示了选择器已经对象的原来类,也指出了已释放的消息接受对象的指针值。如果你在做功能调试,这个信息是非常有用的,如果与合适的工具配合使用,例如Instruments,那会有更好的效果。
小结
- 当一个对象释放时,它可以转化为一个僵尸对象而不用释放。这个功能仅能通过
NSZombieEnabled
环境变量开启。 - 对象转换为僵尸对象是通过更改它的isa指针值去指向特殊僵尸类。僵尸类可以响应所有发给僵尸对象的消息,然后会打印一条信息,程序终止。
避免使用retainCount
Objective-C
使用引用技术去管理内存(看第29节)。每个对象都有一个计数器去确定有多少别的事物希望它保持存活。当一个对象呗创建,它的引用计数一样大于0。retain
和release
会使引用计数增加或减少。当引用计数为0时,对象被释放销毁。
NSObject
的协议定义了一个方法允许你获取某个对象当前的引用计数:
|
|
但是,ARC已经废弃了这个方法。实际上,在ARC下,如果你试图调用它,编译器将会抛出一个错误,就像retain、release、autorelease
这些方法一样。即使官方已经废弃了它,但还是有人无解它,并且应该避免使用它。如果你不使用ARC,你仍然可以使用它,并且没有编译器错误。所以理解为什么要避免使用这个方法是非常重要的。
这个方法看起来很有用的。因为它返回了引用计数,毕竟这个属性对每个对象都是很重要的。但个问题就在于,引用计数通常与开发者所应关注的没什么关系。即使你只是在调试环境下使用,它也不是有帮助的。
避免使用的主要原因是这个方法返回的值引用计数是在某个时间的值。因为这个值不包含将要减去的引用计数,例如在一个自动释放池中,这个值并不能真的代表引用计数。因此,下面的代码是有问题的:
|
|
这代码的第一个错误点在于它没有考虑可能有自动释放的情况,只是一直减少引用计数直到释放为止。如果对象处于一个自动释放池,这时自动释放池释放了,那么这个对象也会释放,那么肯定会崩溃了。
其次,这代码是危险的,因为retainCount
永远不会返回0;因为有时系统会对对象释放行为进行优化,意思是当它释放时,如果它的引用计数是1,会直接执行销毁。否则,才会减少引用计数并销毁。因此,引用计数永远不会是1。不幸的是,即使这种代码有时能正常运行,那也是运气大于判断。当对象回收后,如果while循环还在运行,那么现代运行时很可能直接让其崩溃。
这样的代码不该使用。这样的代码实现的应该交由内存管理去处理。当你想让某个对象释放时,你应该确保它在该处已经达到引用计数平衡了。如果没有释放,应该查明是否有引用计数未平衡并且查明为什么没释放。
你可能尝试使用retainCount
并且疑惑为什么返回值有时候非常巨大。例如,下面的代码:
|
|
在64位Mac OS X 10.8.2,使用Clang 4.1编译,输出如下:
|
|
第一个值是2^64 - 1,第二个值是2^63 - 1。对象的引用计数为什么这么大,因为它们代表了单一对象。如果可以,系统会将字符串实现为单一对象的。如果像本例一样,字符串是一个编译期常量。在这种情况下,编译器会制作一个特殊的对象,代替NSString
对象在二进制文件中的位置,并在运行时使用常量代替它。在这种设计下,NSNumber
也是类似的对象;指针包含了所有的数字信息。运行时系统会在消息转发时发现这个标签,并对其做相应操作,使其行为和一个真正的NSNumber
对象一样。这种优化仅使用这种特定的值,比如例子中的浮点数值就没有做这种优化,它的引用计数就是1。
另外,这种单一对象的引用计数永远不会发生改变。保留和释放都是空操作。即使两个单一对象,它们的引用计数值也不一样,系统借此指出,不要考虑使用引用计数作什么。如果你更改NSNumber
对象的引用计数,当它是一个标签指针时,那么代码就会发生错误。
那么,你只想用retainCount
去调试呢?即使这样,它也没什么用。这个值可能没你想的那么精确,就像它处于自动释放池中。而且,其他库也可能去释放或者保留对象。如果你检查引用计数,你可能以为自己错了,因为可能有其余库修改了它的引用计数啊。例如,下面的代码:
|
|
它的引用计数是多少?可以是任何值吧。doSomethingWithObject:
方法里面集合可能添加了这个对象,增加引用计数。或者可能多次保留对象并自动释放它,其中的某些自动释放池可能稍后才会清空。所以这引用计数没你想的那样有用。
你什么时候可以使用引用计数?最好的答案是:永远不用,尤其是苹果在引入ARC后已经将其废弃。
小结
- 引用计数看似有用,实则无用,因为任何时间点,绝对引用计数都无法代表对象的完整生命面貌。
- 当在ARC环境下时,
retainCount
方法被废弃,如果使用它还会导致编译器报错。